5.09. Работа с базами данных в Kotlin
Работа с базами данных в Kotlin
Kotlin как язык программирования не содержит встроенной поддержки работы с базами данных — как и большинство универсальных языков, он полагается на сторонние библиотеки и стандартные интерфейсы для интеграции с внешними системами хранения. Однако сама архитектура Kotlin (в первую очередь его строгая статическая типизация, выразительный синтаксис, поддержка функциональных конструкций и расширяемость) делает его особенно удобным для построения надёжных, компактных и легко поддерживаемых решений по работе с данными. В данном разделе мы последовательно рассмотрим:
- как Kotlin взаимодействует с файлами как с примитивным способом хранения,
- как организуется обработка данных в памяти,
- как осуществляется подключение к внешним системам хранения,
- какие модели и инструменты применяются при взаимодействии с реляционными и нереляционными базами данных,
- как реализованы популярные ORM-библиотеки, созданные специально под Kotlin (Exposed, Ktorm), и в чём их отличия от классических решений, таких как Hibernate.
Все рассматриваемые механизмы демонстрируются в контексте реального применения — без упрощений, но с акцентом на понимание принципов работы, а не на копирование готовых решений.
1. Работа с файлами в Kotlin
Файловая система — это один из наиболее базовых уровней хранения данных. Kotlin не предоставляет собственных классов для файлового ввода-вывода, но полностью интегрируется со стандартными библиотеками Java (java.io, java.nio.file). Это позволяет использовать проверенные, кроссплатформенные и высокопроизводительные инструменты без избыточной абстракции.
Базовые операции
Для работы с файлами в Kotlin используются объекты типа java.io.File (устаревший, но простой в использовании) и java.nio.file.Path (современный, более гибкий). В Kotlin эти классы дополнены расширениями, упрощающими чтение и запись:
val file = File("data.txt")
file.writeText("Привет, Kotlin!") // Запись строки
val content = file.readText() // Чтение всего файла как строки
val lines = file.readLines() // Чтение по строкам в список
Для бинарных данных применяются потоковые операции:
val bytes = byteArrayOf(0x01, 0x02, 0x03)
file.writeBytes(bytes)
val readBytes = file.readBytes()
Эти методы являются синхронными и блокирующими — они завершаются только после фактического завершения операции ввода-вывода. В многопоточных приложениях их следует использовать вне основного потока (например, в Dispatchers.IO при использовании Kotlin Coroutines).
Кодировки и локализация
Kotlin по умолчанию использует кодировку UTF-8 в методах writeText, readText, readLines, что соответствует современным стандартам. При необходимости кодировку можно указать явно:
file.writeText("Текст", Charsets.UTF_16)
Это особенно важно при работе с унаследованными системами, где может применяться Windows-1251 или ISO-8859-1.
Контекстные менеджеры и безопасность ресурсов
Хотя readText() и writeText() скрывают управление потоками внутри себя, при ручной работе с InputStream/OutputStream или Reader/Writer необходимо гарантировать корректное освобождение ресурсов. Kotlin предлагает идиоматичное решение — расширение use, реализующее паттерн try-with-resources:
FileInputStream("data.bin").use { input ->
// Работа с input
// input.close() будет вызван автоматически
}
Метод use принимает лямбду, внутри которой можно безопасно использовать ресурс. При выходе из блока, в том числе при возникновении исключения, вызывается close().
Файловая система как хранилище
Несмотря на простоту, файлы редко используются в продакшен-приложениях для хранения структурированных данных по следующим причинам:
- отсутствие транзакционности;
- сложность реализации конкурентного доступа;
- отсутствие индексов и эффективных методов поиска;
- высокий риск повреждения данных при сбое;
- необходимость ручной сериализации/десериализации.
Однако файлы остаются важным элементом вспомогательной инфраструктуры: логирование, кэширование, хранение конфигураций, загрузка/выгрузка архивов. В этих сценариях Kotlin проявляет себя как лаконичный и надёжный инструмент.
2. Работа с данными в памяти
Прежде чем данные попадут в базу или файл, они существуют в оперативной памяти. Kotlin предоставляет богатую систему типов и коллекций, позволяющую эффективно моделировать доменные сущности и их связи.
Типы данных и неизменяемость
Kotlin делает акцент на неизменяемости по умолчанию. Объявление как val x = 42, так и val list = listOf(1, 2, 3) создаёт ссылку, которую нельзя переназначить, а в случае коллекций — структуру, которую нельзя модифицировать. Для изменения требуется явно выбрать изменяемую форму:
val immutableList = listOf(1, 2, 3) // List<Int> — только для чтения
val mutableList = mutableListOf(1, 2, 3) // MutableList<Int> — можно менять
Этот подход снижает когнитивную нагрузку и уменьшает количество ошибок, связанных с неожиданными побочными эффектами — особенно при передаче данных между компонентами (например, между слоями приложения: контроллер → сервис → репозиторий).
Data-классы как основа доменной модели
Одним из ключевых инструментов моделирования данных в Kotlin являются data-классы. Они автоматически генерируют:
- конструктор с параметрами всех свойств;
- реализации
equals()иhashCode()на основе значений свойств; - метод
toString()для отладки; - функцию
copy()для создания изменённых копий (поддержка иммутабельного стиля).
Пример:
data class User(
val id: Long,
val name: String,
val email: String,
val createdAt: Instant
)
copy() позволяет безопасно изменять отдельные поля без побочных эффектов:
val user = User(1, "Анна", "anna@example.com", Instant.now())
val updated = user.copy(email = "new@example.com")
Data-классы идеально подходят для передачи данных между уровнями приложения (DTO), для кэширования, для сериализации и, конечно, для отображения строк таблиц баз данных.
Сериализация
Для сохранения данных в файл или передачи по сети требуется сериализация. Kotlin поддерживает несколько подходов:
- JSON через
kotlinx.serialization— официальный, типобезопасный, поддерживает вложенные структуры, полиморфизм, кастомные сериализаторы; - Protobuf, CBOR, Properties — также поддерживаются через соответствующие модули
kotlinx.serialization; - Java-Serializable — наследуется от Java, но не рекомендуется из-за нестабильности формата и отсутствия контроля версий.
Пример с JSON:
@Serializable
data class User(val id: Long, val name: String)
val json = Json.encodeToString(User(1, "Иван"))
val user = Json.decodeFromString<User>(json)
Сериализация — обязательный этап при работе с внешними системами. От её корректности зависит стабильность взаимодействия между компонентами системы.
3. Работа с базами данных
Под «работой с базой данных» в контексте приложения на Kotlin понимается организация постоянного, структурированного и надёжного хранения данных. На этом уровне возникают следующие задачи:
- установление соединения с СУБД;
- выполнение запросов (чтение, запись, модификация);
- управление транзакциями;
- преобразование данных между реляционной моделью (таблицы, строки, столбцы) и объектной моделью (классы, свойства, связи);
- обработка ошибок и восстановление после сбоев;
- обеспечение безопасности (предотвращение SQL-инъекций);
- работа с пулами соединений для повышения производительности.
Kotlin, как и Java, опирается на интерфейс java.sql.*, входящий в состав Java SE. Однако его низкоуровневый характер (ручное управление PreparedStatement, ResultSet, Connection) делает код многословным и подверженным ошибкам. Поэтому на практике применяются более высокоуровневые абстракции.
4. Взаимодействие с СУБД
Есть три основных уровня работы с базами данных в Kotlin-экосистеме:
| Уровень | Описание | Примеры |
|---|---|---|
| Низкоуровневый (JDBC) | Прямое использование java.sql.Connection, PreparedStatement, ResultSet. Полный контроль, но высокая трудоёмкость и риск ошибок. | java.sql.DriverManager, HikariCP (пул соединений) |
| SQL-мапперы | Автоматическое сопоставление результатов SQL-запросов с Kotlin-классами. Запросы пишутся вручную, но обработка результата упрощена. | JDBI, KotliQuery, MapStruct + кастомные обёртки |
| ORM (Object-Relational Mapping) | Полная абстракция над SQL: объекты в коде отображаются на таблицы, свойства — на столбцы, коллекции — на связи. Запросы могут формулироваться в виде вызовов методов или DSL. | Exposed, Ktorm, Hibernate (через hibernate-kotlin или JPA) |
Выбор уровня зависит от требований проекта:
- для микросервисов с простой моделью данных предпочтителен лёгкий ORM или SQL-маппер;
- для сложных доменных моделей с наследованием, полиморфизмом и кэшированием — полноценный ORM;
- для высоконагруженных сценариев, где важен контроль над SQL, — ручные запросы с маппингом.
Важно понимать: Kotlin не навязывает никакой конкретный подход. Это позволяет использовать лучший инструмент под задачу — в отличие от некоторых фреймворков, где ORM «вшит» в архитектуру.
5. Низкоуровневая работа: JDBC и его ограничения в Kotlin-контексте
Java Database Connectivity (JDBC) — стандартный API для доступа к реляционным СУБД. Он предоставляет унифицированный интерфейс (DriverManager, Connection, Statement, ResultSet) и реализуется драйверами от поставщиков СУБД: pgjdbc для PostgreSQL, mysql-connector-j для MySQL, mariadb-java-client, sqlite-jdbc и др.
Как работает типичный JDBC-запрос
val conn = DriverManager.getConnection(
"jdbc:postgresql://localhost:5432/mydb",
"user", "password"
)
val stmt = conn.prepareStatement("SELECT id, name FROM users WHERE id = ?")
stmt.setLong(1, 101L)
val rs = stmt.executeQuery()
while (rs.next()) {
val id = rs.getLong("id")
val name = rs.getString("name")
println("$id: $name")
}
rs.close()
stmt.close()
conn.close()
Этот код — корректный Kotlin, но он страдает от нескольких проблем:
- Многословность: каждая операция требует явного управления ресурсами.
- Подверженность ошибкам: забытый
close(), неправильный индекс параметра (1вместо0), непроверенныйnull. - Отсутствие типобезопасности:
getString("name")возвращаетString?, но компилятор не проверяет, существует ли столбецnameв результате. - Несовместимость с корутинами:
executeQuery()блокирует поток.
Пулы соединений
Открытие нового соединения — дорогая операция. В production-средах используются пулы соединений, которые удерживают заранее инициализированные Connection и выдают их по запросу. Популярные реализации:
- HikariCP — самый быстрый и лёгкий пул, де-факто стандарт в Kotlin/JVM-мире;
- Tomcat JDBC Pool, C3P0, DBCP2 — альтернативы, чаще встречаются в legacy-проектах.
Пример инициализации HikariCP:
val config = HikariConfig().apply {
jdbcUrl = "jdbc:postgresql://localhost/mydb"
username = "user"
password = "pass"
maximumPoolSize = 20
connectionTimeout = 3000
}
val dataSource = HikariDataSource(config)
Теперь dataSource.connection возвращает соединение из пула, и его тоже обязательно закрывать (хотя физическое закрытие не происходит — соединение возвращается в пул).
Kotlin-расширения для облегчения JDBC
Комьюнити создало множество расширений, сокращающих шаблонный код:
inline fun <T> Connection.useTransaction(block: Connection.() -> T): T {
return try {
autoCommit = false
val result = block()
commit()
result
} catch (e: Exception) {
rollback()
throw e
} finally {
autoCommit = true
}
}
inline fun <reified T> ResultSet.mapRow(mapper: ResultSet.() -> T): List<T> {
val list = mutableListOf<T>()
while (next()) {
list += mapper()
}
return list
}
Использование:
dataSource.connection.use { conn ->
conn.useTransaction {
val users = prepareStatement("SELECT id, name FROM users")
.executeQuery()
.use { rs -> rs.mapRow { User(getLong("id"), getString("name")!!) } }
// ...
}
}
Такой подход уже ближе к идиоматичному Kotlin: безопасность ресурсов, лаконичность, читаемость. Однако он по-прежнему требует написания SQL вручную и не решает проблему маппинга «таблица ↔ объект» при сложных схемах.
6. Паттерны проектирования для работы с данными
Качественное приложение разделяет ответственности между уровнями. В работе с БД выделяют следующие паттерны:
DAO (Data Access Object)
Изолирует логику доступа к данным от бизнес-логики. Каждый DAO отвечает за одну сущность и предоставляет методы вроде findById, save, delete.
interface UserDao {
fun findById(id: Long): User?
fun save(user: User): User
fun delete(id: Long)
}
Реализация может использовать JDBC, Exposed или любой другой инструмент. Преимущество — простота тестирования (можно подменить реализацией на in-memory базе или моке).
Repository
Более высокоуровневый, чем DAO. Repository работает с агрегатами (группами связанных сущностей), поддерживает критерии поиска (спецификации), кэширование, пагинацию. Часто используется в DDD (Domain-Driven Design).
interface UserRepository : CrudRepository<User, Long> {
fun findByEmail(email: String): User?
fun findActive(page: Int, size: Int): Page<User>
}
Spring Data предоставляет такую абстракцию «из коробки», но её можно реализовать и самостоятельно.
Unit of Work
Паттерн, отслеживающий изменения в объектах в течение одного логического действия (например, одного HTTP-запроса) и применяющий их в рамках одной транзакции. Hibernate реализует его через Session, Exposed — через Transaction.
Преимущество: избегается частичное обновление состояния при ошибке в середине операции.
7. SQL-мапперы: баланс контроля и удобства
SQL-мапперы сохраняют прямой контроль над SQL, но автоматизируют сопоставление результата с Kotlin-объектами.
KotliQuery
Лёгкая библиотека, построенная поверх JDBC, с поддержкой DSL для построения запросов и типобезопасного маппинга.
Пример:
val users = sessionOf(dataSource).use { session ->
session.list("SELECT id, name FROM users WHERE active = ?") {
bind(true)
map { row -> User(row.long("id"), row.string("name")) }
}
}
Особенности:
- Поддержка
NamedParameterJdbcTemplate-подобного синтаксиса (WHERE active = :active); - Интеграция с HikariCP;
- Совместимость с корутинами (асинхронная версия —
asyncSessionOf); - Минимальный оверхед.
JDBI
Более зрелое решение от разработчиков Dropwizard. Использует аннотации и интерфейсы:
@RegisterRowMapper(UserMapper::class)
interface UserDao {
@SqlQuery("SELECT * FROM users WHERE id = :id")
fun findById(@Bind("id") id: Long): User?
@SqlUpdate("INSERT INTO users (name, email) VALUES (:name, :email)")
@GetGeneratedKeys
fun insert(@BindBean user: User): Long
}
Преимущества JDBI:
- Чёткое разделение интерфейса и реализации;
- Поддержка плагинов (например, для Kotlin data-классов);
- Гибкость: можно комбинировать аннотации и программное построение запросов.
Оба подхода хороши, когда:
- требуется полный контроль над SQL (оптимизация, оконные функции, CTE);
- схема БД не меняется часто;
- команда владеет SQL на продвинутом уровне.
8. ORM: зачем нужна абстракция над SQL?
ORM (Object-Relational Mapping) решает фундаментальную проблему impedance mismatch — несоответствие между реляционной моделью (таблицы, нормализация, внешние ключи) и объектной моделью (иерархии, наследование, полиморфизм, поведение).
Основные функции ORM:
- декларативное описание сущностей (аннотации или DSL);
- автоматическая генерация DDL (миграции, схемы);
- CRUD-операции без написания SQL;
- ленивая и жадная загрузка связей;
- кэширование (1st/2nd level);
- управление транзакциями на уровне бизнес-операций.
Важно: ORM не заменяет знание SQL. Наоборот — эффективное использование ORM требует понимания, какой SQL он генерирует. Антипаттерн «N+1 selects» (множественные запросы при итерации по коллекции связанных сущностей) — прямое следствие непонимания этого.
Hibernate/JPA в Kotlin
Hibernate остаётся самым распространённым ORM в JVM-мире. С Kotlin он совместим, но требует аккуратности:
- data-классы должны быть
open(Hibernate использует прокси-наследование); - свойства —
var, иначе невозможно изменение через reflection; - аннотации из
javax.persistenceилиjakarta.persistence.
Пример:
@Entity
@Table(name = "users")
open class User(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null,
@Column(nullable = false)
var name: String = "",
@ManyToOne(fetch = FetchType.LAZY)
var department: Department? = null
)
Минусы:
- тяжёлый runtime (инициализация, метаданные);
- сложный конфигурационный файл (
persistence.xml); - ограничения Kotlin (необходимость
open,var) снижают преимущества языка.
Отсюда возникает потребность в Kotlin-first ORM.
9. Exposed: лёгкий, типобезопасный, Kotlin-нативный ORM
Exposed (разрабатывается JetBrains) — это типобезопасный DSL для SQL, сочетающий два режима работы:
- DAO API — классический ORM-стиль с наследованием от
Entity; - SQL DSL API — программное построение запросов с компиляционной проверкой типов.
Архитектурные особенности
- Полностью написан на Kotlin;
- Не требует reflection — работает через встроенные DSL и extension-функции;
- Поддерживает транзакции через
transaction { }; - Совместим с корутинами (через
suspendedTransactionAsync); - Поддерживает основные СУБД: PostgreSQL, MySQL, SQLite, H2, Oracle, SQL Server.
Описание схемы — типобезопасно
Схема описывается как объект, наследующий Table:
object Users : Table() {
val id = long("id").autoIncrement().primaryKey()
val name = varchar("name", length = 50)
val email = varchar("email", length = 100).uniqueIndex()
val createdAt = datetime("created_at").defaultExpression(CurrentDateTime())
}
Компилятор проверяет:
- наличие столбца при обращении:
Users.name; - совместимость типов:
Users.name eq "Иван"— валидно,Users.id eq "abc"— ошибка на этапе компиляции.
Выполнение запросов (SQL DSL)
val users = Users.selectAll().where { Users.name eq "Иван" }.toList()
val count = Users.slice(Users.id.count()).selectAll().where { Users.createdAt greaterEq oneWeekAgo }.single()[Users.id.count()]
Преимущества DSL:
- запросы строятся программно — легко параметризовать, переиспользовать части;
- безопасность от SQL-инъекций (все значения передаются как параметры);
- подсказки IDE и автодополнение работают на полную мощность.
DAO API (Entity-based)
class User(id: EntityID<Long>) : LongEntity(id) {
companion object : LongEntityClass<User>(Users)
var name by Users.name
var email by Users.email
var createdAt by Users.createdAt
}
// Использование
val user = User.new {
name = "Анна"
email = "anna@example.com"
}
user.refresh() // синхронизация с БД
DAO API удобен для CRUD, но менее гибок при сложных выборках. Рекомендуется использовать SQL DSL как основной инструмент, а DAO — для простых операций.
Производительность и ограничения
- Exposed генерирует минимальный, читаемый SQL — близкий к написанному вручную;
- Нет overhead-а на reflection или proxy;
- Ограничения:
- Нет встроенной поддержки миграций (используются Flyway/Liquibase отдельно);
- Нет вторичного кэша;
- Ленивая загрузка связей не поддерживается — только жадная (
joinвручную или черезwith-конструкции).
Exposed идеален для:
- микросервисов;
- приложений с умеренной сложностью домена;
- проектов, где важна прозрачность SQL и контроль над запросами.
10. Ktorm: ORM с функциональным уклоном
Ktorm — это ORM, спроектированный с акцентом на иммутабельность, выразительность DSL и чистоту функционального подхода. В отличие от Exposed, он не предоставляет DAO-стиль с изменяемыми сущностями, а строит всю работу вокруг неизменяемых data-классов и декларативных запросов.
Философия и ключевые принципы
-
Иммутабельность по умолчанию
Все сущности — этоdata classсval, а неvar. Изменения осуществляются черезcopy(), что исключает побочные эффекты и упрощает рассуждение о состоянии. -
Типобезопасный SQL DSL без reflection
Ktorm строит запросы, используя вложенные лямбды и extension-функции. Все операции проверяются на этапе компиляции: типы столбцов, совместимость выражений, наличие полей. -
Минимальная зависимость от runtime
Нет необходимости в байткоде-манипуляциях, прокси или аннотациях. Всё — через Kotlin-код. -
Поддержка функциональных паттернов
Запросы можно компоновать, частично применять, кэшировать как значения, передавать как параметры.
Описание схемы: таблицы и столбцы
Схема определяется через объекты, представляющие таблицы. Каждый столбец — это экземпляр Column<T>:
object Users : Table<Nothing>("users") {
val id = int("id").primaryKey()
val name = varchar("name")
val email = varchar("email")
val createdAt = datetime("created_at")
}
Обратите внимание: Table<Nothing> — так как Ktorm не привязывает таблицу к конкретному классу сущности. Связь устанавливается отдельно.
Сущности
data class User(
val id: Int,
val name: String,
val email: String,
val createdAt: LocalDateTime
)
Нет наследования, нет прокси, нет open/var. Это чистая Kotlin-модель.
Отображение: Row Mapper
Сопоставление строки результата и сущности задаётся явно через rowMapper:
val User.mapper: RowMapper<User> = { row ->
User(
id = row[Users.id],
name = row[Users.name],
email = row[Users.email],
createdAt = row[Users.createdAt]
)
}
Метод row[column] типобезопасен: компилятор знает, что row[Users.id] возвращает Int, а не Any?.
Запросы
Пример выборки:
val users = database.from(Users).select().map { it.mapper }.toList()
Фильтрация и сортировка:
val activeUsers = database
.from(Users)
.select()
.where { Users.createdAt greaterEq oneWeekAgo }
.orderBy(Users.name.asc())
.map { it.mapper }
.toList()
Сложные запросы с JOIN:
val query = database
.from(Users.join(Departments, JoinType.INNER, Users.departmentId eq Departments.id))
.select(Users.name, Departments.name)
.where { Users.active eq true }
val results = query.map { row ->
UserWithDept(
userName = row[Users.name],
deptName = row[Departments.name]
)
}.toList()
Особенность: join() возвращает новую виртуальную таблицу, составленную из нескольких, и к ней можно применять select(), where(), groupBy().
Вставка и обновление
Ktorm не предоставляет save() или update() на уровне сущности — только декларативные операторы:
// INSERT
database.insert(Users) {
set(Users.name, "Мария")
set(Users.email, "maria@example.com")
set(Users.createdAt, LocalDateTime.now())
}
// UPDATE
database.update(Users) {
set(Users.email, "new@example.com")
where { Users.id eq 42 }
}
// DELETE
database.delete(Users) { Users.id eq 42 }
Это ближе к SQL, но с гарантией типобезопасности. Отсутствие «магического» save() снижает риск неявных изменений.
Продвинутые возможности
- Подзапросы:
Users.id inSubQuery { ... }; - Агрегаты:
count(),sum(),avg(),groupBy; - Оконные функции:
rowNumber().over { partitionBy(Users.departmentId).orderBy(Users.salary.desc()) }; - Кастомные функции: регистрация собственных SQL-функций;
- Пакетные операции:
insertAndGenerateKey { ... },batchInsert,batchUpdate.
Производительность и ограничения
-
Плюсы:
- Отсутствие reflection → быстрая инициализация;
- Генерируемый SQL предсказуем и оптимизируем;
- Полная совместимость с корутинами (можно оборачивать
withContext(Dispatchers.IO)); - Лёгкий вес: ~500 KB JAR без зависимостей.
-
Минусы:
- Нет встроенной поддержки миграций;
- Нет кэширования;
- Отсутствие автоматической поддержки связей «один-ко-многим» (нужно вручную писать
JOINили постобработку); - Менее зрелая документация по сравнению с Exposed (на 2025 год — активно развивается, но сообщество меньше).
Когда выбирать Ktorm?
- Когда приоритет — чистота архитектуры, иммутабельность, функциональный стиль;
- В системах, где доменная модель строится на алгебраических типах данных (sealed classes,
Result<T>); - При интенсивном использовании корутин и реактивных потоков (Ktorm легко интегрируется с
Flow); - В проектах, где разработчики предпочитают явное управление запросами, а не «магию» ORM.
11. Сравнение
| Критерий | Exposed | Ktorm | Hibernate (JPA) |
|---|---|---|---|
| Парадигма | Гибрид: DAO + SQL DSL | Функциональный DSL | Объектно-ориентированный ORM |
| Сущности | Изменяемые (var, open) | Неизменяемые (val, data class) | Изменяемые (var, open, прокси) |
| Типобезопасность | Высокая (DSL), частичная (DAO) | Очень высокая (всё в DSL) | Умеренная (аннотации → runtime-ошибки) |
| Reflection | Нет (DSL), есть (DAO через by) | Нет | Да (интенсивно) |
| Производительность инициализации | Низкая (мгновенная) | Низкая | Высокая (метамодель, прокси) |
| Сложность SQL-генерации | Простой, прозрачный | Простой, прозрачный | Сложный, часто требует оптимизации |
| Ленивая загрузка | Нет | Нет | Да |
| 2nd-level cache | Нет | Нет | Да (через Ehcache, Infinispan) |
| Миграции | Нет (сторонние: Flyway) | Нет (Flyway/Liquibase) | Да (Hibernate SchemaUpdate, но не для production) |
| Поддержка корутин | suspendedTransactionAsync | Через withContext(Dispatchers.IO) | Нет (требуется @Transactional(propagation = REQUIRES_NEW) + адаптеры) |
| Обучение | Легко (если знаешь SQL) | Средне (нужен функциональный mindset) | Сложно (много концепций: Session, Persistence Context, Flush) |
| Сообщество | Крупное (JetBrains, Spring Boot интеграции) | Растущее (активные maintainer’ы, но меньше adoption) | Очень крупное (стандарт де-факто) |
Рекомендации по выбору:
- Стартап / микросервис / PoC → Exposed (быстро, просто, легко уйти вручную при росте).
- Финтех / аналитика / ETL / функциональная архитектура → Ktorm (иммутабельность, композируемость, прозрачность).
- Корпоративное enterprise-приложение с комплексной моделью (наследование, полиморфизм, аудит) → Hibernate (неизбежность legacy-совместимости, поддержка инструментов, зрелые паттерны).
Примечание: в одном проекте можно комбинировать подходы. Например, Exposed/Ktorm для основного домена, а JDBC напрямую — для аналитических запросов с CTE и оконными функциями.
12. Работа с NoSQL в Kotlin
Хотя Kotlin чаще ассоциируется с реляционными БД, он отлично подходит и для NoSQL.
MongoDB
Официальный драйвер mongodb-driver-kotlin (от MongoDB Inc.) предоставляет корутин-совместимый API:
val collection = db.getCollection<Document>("users")
// Вставка
collection.insertOne(
documentOf(
"name" to "Олег",
"email" to "oleg@example.com",
"tags" to listOf("dev", "kotlin")
)
)
// Поиск (типобезопасно через кодеки)
val codecRegistry = fromRegistries(
MongoClientSettings.getDefaultCodecRegistry(),
fromProviders(PojoCodecProvider.builder().automatic(true).build())
)
val userCollection = db.getCollection<User>("users").withCodecRegistry(codecRegistry)
val user = userCollection.find(eq("email", "oleg@example.com")).first()
Можно использовать KMongo — обёртку с улучшенным DSL и поддержкой data-классов «из коробки».
Redis
Для кэширования и быстрых операций — Lettuce или Redisson:
val redis = RedisClient.create("redis://localhost")
val conn = redis.connect()
val sync = conn.sync()
sync.set("user:101:name", "Елена")
val name = sync.get("user:101:name")
Асинхронная версия — conn.async(), реактивная — conn.reactive().
Для типобезопасности — сериализация через kotlinx.serialization:
val json = Json.encodeToString(user)
sync.set("user:101", json)
val restored = Json.decodeFromString<User>(sync.get("user:101")!!)
Гибридные архитектуры
Часто применяется:
- PostgreSQL — для транзакционных данных (заказы, пользователи);
- Redis — для сессий, рейт-лимитов, кэша;
- Elasticsearch/MongoDB — для поиска и аналитики.
Kotlin позволяет унифицировать обработку через абстракции:
interface UserRepository {
suspend fun findById(id: Long): User?
suspend fun save(user: User)
}
class PostgresUserRepository(val db: org.jetbrains.exposed.sql.Database) : UserRepository { ... }
class CachedUserRepository(
private val cache: RedisClient,
private val delegate: UserRepository
) : UserRepository {
override suspend fun findById(id: Long): User? {
val cached = cache.get("user:$id")?.let { Json.decodeFromString<User>(it) }
return cached ?: delegate.findById(id).also { u ->
if (u != null) cache.set("user:$id", Json.encodeToString(u))
}
}
}
13. Практики
Миграции: Flyway и Liquibase
Kotlin не имеет встроенного инструмента миграций, но интегрируется со всеми основными.
Flyway (SQL-ориентированный):
- миграции —
.sql-файлы:V1__create_users.sql,V2__add_email_index.sql; - запуск через
Flyway.configure().dataSource(...).load().migrate().
Liquibase (XML/YAML/JSON):
- декларативные изменения:
<addColumn tableName="users">...; - поддержка генерации diff между состояниями.
Можно писать миграции на Kotlin (через kotlin-script или kotlinc), но это редкость — предпочтителен SQL для прозрачности.
Безопасность
- SQL-инъекции: у Exposed и Ktorm — невозможны благодаря параметризации. В JDBC — только
PreparedStatement, никогда конкатенация строк. - Утечки данных: избегайте логирования
ResultSetилиUser(password = "..."). Используйте@JsonIgnoreили кастомныеtoString()в data-классах. - Права доступа: учётные записи приложения должны иметь минимальные привилегии (только
SELECT/INSERT/UPDATE/DELETEна нужные таблицы, безDROP,CREATE,GRANT).
Тестирование
- Unit-тесты — моки репозиториев (
Mockk,Mockito-Kotlin). - Интеграционные тесты:
- Testcontainers — запуск реальной СУБД в Docker-контейнере;
- H2 in-memory — для простых случаев (но помните: синтаксис H2 ≠ PostgreSQL);
- Transactional rollback — каждый тест оборачивается в транзакцию, которая откатывается после завершения.
Пример с Testcontainers:
class UserRepositoryTest {
companion object {
@Container
val postgres = PostgreSQLContainer<Nothing>("postgres:15")
.apply { start() }
}
private lateinit var db: Database
@BeforeEach
fun setup() {
db = Database.connect(
url = postgres.jdbcUrl,
driver = "org.postgresql.Driver",
user = postgres.username,
password = postgres.password
)
// Выполнить миграции...
}
@Test
fun `сохранение и выборка`() {
transaction(db) {
User.new { name = "Тест" }
}
val count = transaction(db) {
Users.selectAll().count()
}
assertEquals(1, count)
}
}